Skip to content

Installable Modelibr: tray host app + desktop client (Windows, macOS, Linux)#495

Open
Papyszoo wants to merge 54 commits into
mainfrom
feat/native-installer
Open

Installable Modelibr: tray host app + desktop client (Windows, macOS, Linux)#495
Papyszoo wants to merge 54 commits into
mainfrom
feat/native-installer

Conversation

@Papyszoo

@Papyszoo Papyszoo commented May 14, 2026

Copy link
Copy Markdown
Owner

Summary

Makes Modelibr installable for non-technical users who can't set up the Docker Compose stack. Download a .exe / .dmg / .AppImage from GitHub Releases, install, and run a fully self-contained Modelibr — bundled frontend, WebApi, asset-processor workers, Node runtime, and PostgreSQL — with no Docker or .env files.

The native track is two separate Electron apps:

App Path What it is
Modelibr (host) src/desktop/ The full self-contained stack, run from a tray / menu-bar icon. Owns the runtime + configuration.
Modelibr Client (optional) src/desktop-client/ A thin "extended website" window that opens a running host in its own app frame instead of a browser tab. Bundles no runtime.

The Docker Compose setup is unchanged and remains the source-based path.

Host app (src/desktop/)

A tray-is-the-app host: closing the status window only hides it; quitting happens from the tray. On macOS the dock icon is hidden; on Linux it falls back to setContextMenu (tray click events are unreliable there). Tray creation is non-fatal, so headless environments (CI under xvfb) still boot and serve the app.

  • ProcessManager — starts PostgreSQL → WebApi → workers in order; respawns workers on unexpected exit; probeStatus() / isPostgresRunning() feed the status window.
  • EdgeServer — Express reverse proxy on the public app port (default 3010); serves the frontend, proxies /api and /modelibr (WebDAV), terminates /api/native/runtime.
  • Status window (status.html + sandboxed preload.cjs + IPC) shows:
    • live Backend / Database / Asset Processor health,
    • the frontend URL (open in browser / copy),
    • a Configuration panel — app port, worker process count, jobs per worker, GPU acceleration (persisted via runtimeConfig; worker changes apply live, a port change is flagged restart-required),
    • an Install Desktop Client shortcut,
    • data-folder / restart / quit.
  • Update checking — on boot and on demand the host checks the GitHub Releases API, compares the latest tag against the running version, and surfaces an update prompt in the tray and status window. The update action opens the release page to download the new installer. (Dependency-free and signing-independent; one-click in-app install via electron-updater is a follow-up.)

Client app (src/desktop-client/)

A minimal Electron shell that loadURLs a running host. The host URL is persisted in userData and editable from a local connection page (connect.html) shown automatically when the host is unreachable. External links open in the system browser.

Backend / asset-processor

  • DISABLE_HTTPS_LISTENER + HTTP_PORT in WebApi so it runs plain HTTP behind the host's edge server.
  • WEBDAV_PROBE_BASE_URL makes the WebDAV probe work in both Docker (nginx) and native (edge server) modes.
  • ENABLE_GPU_RENDERING toggle in the asset processor — swap swiftshader for real GPU Chromium flags.

CI (.github/workflows/native-release.yml)

  • build-installers — matrix builds the host .exe / .dmg / .AppImage / .deb, bundling the full runtime.
  • test-installers — installs the host silently, starts it (xvfb on Linux), waits for http://127.0.0.1:3010, exercises /, /api/native/runtime, /api/health, then uninstalls and asserts the binary is gone while userData/ survives.
  • build-client — matrix builds the client installers (no runtime bundling).

Triggers on release publication and workflow_dispatch; always uploads artifacts.

Docs

README + docs site describe the two installers (host vs client), the tray configuration panel, update checking, and the data-folder location, alongside the Docker quick start.

Test plan

  • Run the workflow via workflow_dispatch and confirm build-installers, test-installers, and build-client pass on Windows, macOS, and Linux
  • Install the host on each OS: verify it boots to the tray, the status window shows service health + frontend URL, and the Configuration panel applies port / worker / GPU changes (port change prompts restart)
  • Install the client, point it at the host URL, confirm it loads the app and the connection page appears when the host is down
  • Publish a newer release and confirm the host shows the update prompt
  • Confirm the Docker setup still works unchanged

Follow-ups (intentionally not in this PR)

  • One-click in-app auto-update via electron-updater (needs release update metadata + macOS code-signing)
  • macOS code-signing + notarization for distribution
  • Universal macOS binary (currently Apple Silicon only)

@Papyszoo Papyszoo changed the base branch from copilot/detect-model-name-collisions to main June 3, 2026 13:27
Papyszoo added 5 commits June 3, 2026 15:49
Add DISABLE_HTTPS_LISTENER + HTTP_PORT so the WebApi can run plain HTTP
behind the native launcher's edge server, and make the WebDAV probe
base URL overridable via WEBDAV_PROBE_BASE_URL so the same probe works
in Docker (nginx) and native (edge server) modes.
ENABLE_GPU_RENDERING swaps swiftshader for real GPU Chromium flags so
the native launcher can opt thumbnail workers into GPU rendering on
machines that support it. Default behaviour (software rendering) is
unchanged.
Introduce src/desktop/ — an Electron launcher that bundles the frontend,
WebApi (self-contained .NET publish), asset processor, Node runtime, and
PostgreSQL into a single installable app.

Components:
- ProcessManager — boots PostgreSQL, WebApi, and N worker processes in
  order; restarts workers on unexpected exit.
- EdgeServer — Express reverse proxy on the public app port, terminates
  /api/native/runtime locally and proxies the rest to WebApi/WebDAV.
- runtimeConfig — sanitised JSON config persisted in userData with
  port/worker/GPU settings.
- prepare-bundle script — stages the runtime tree consumed by
  electron-builder.

Startup shows a loading screen until the backend health-checks pass;
EADDRINUSE on the app port surfaces a friendly message; window-all-closed
follows the platform convention (stay alive on macOS).

Root package.json gains stage:desktop and build:desktop scripts.
Adds the Native Installers workflow:
- build-installers job — stages the runtime, dotnet publishes the WebApi
  per-RID, installs PostgreSQL via the platform package manager, and
  produces .exe / .dmg / .AppImage+.deb via electron-builder.
- test-installers job — downloads the built artifact, installs silently,
  starts the app (xvfb on Linux), waits for the HTTP server on port 3010,
  exercises /, /api/native/runtime, and /api/health, then uninstalls and
  asserts that the app binary is gone and userData survives.

macOS target is Apple Silicon (macos-14, osx-arm64, electronArch arm64).
Artifacts are uploaded on every run so the test job can consume them.
README and the docs site now describe the native installer path
(Windows/macOS/Linux) next to the existing Docker quick start, point
users at Settings > Native Runtime for port and worker tuning, and
clarify which deployment path uses .env vs the in-app config.
@Papyszoo Papyszoo force-pushed the feat/native-installer branch from 5a79174 to 85a7336 Compare June 3, 2026 13:52
Comment thread src/desktop/src/edgeServer.js Fixed
Comment thread src/desktop/src/processManager.js Fixed
Papyszoo added 6 commits June 3, 2026 15:58
Replace the single frontend-in-window Electron app with a tray-based host:
the menu-bar/tray icon owns the runtime, a small status window shows live
backend / database / asset-processor health, the frontend URL (open + copy),
a data-folder shortcut, and a placeholder 'Install Desktop Client' action.

- main.js: Tray + status BrowserWindow (sandboxed preload, IPC bridge);
  tray-is-the-app lifecycle (closing the window hides it; quit only via tray);
  macOS dock hidden; Linux uses setContextMenu (click events unreliable).
- processManager.js: probeStatus() + isPostgresRunning() for per-service health.
- preload.cjs / status.html: sandbox-safe status UI.
Add a Configuration panel to the host status window (app port, worker
process count, jobs per worker, GPU acceleration) backed by IPC handlers
that persist via saveRuntimeConfig and apply worker changes live; an app
port change is flagged as restart-required. Tray creation is now guarded
so headless environments (CI under xvfb, Linux desktops without a
StatusNotifier host) still boot and serve the app.
A thin 'extended website' desktop app that opens a running Modelibr host
in its own window. Bundles no runtime; the host URL is persisted in
userData and configurable from a local connection page shown when the
host is unreachable. External links open in the system browser.
Add a build-client matrix job that packages the client for Windows,
macOS, and Linux. Update README and docs to describe the two installers:
the Modelibr host (tray app with configuration) and the optional client.
The tray host shows the status window during boot, so the old
full-window loading screen is no longer referenced.
The host checks the GitHub Releases API on boot (and on demand) and
compares the latest tag against the running version. When a newer
release exists it surfaces an update prompt in the tray menu and the
status window; the update action opens the release page to download the
new installer. Dependency-free and signing-independent — works on every
platform. (One-click in-app install via electron-updater is a follow-up.)
@Papyszoo Papyszoo changed the title Native desktop installer (Electron) for Windows, macOS, and Linux Installable Modelibr: tray host app + desktop client (Windows, macOS, Linux) Jun 3, 2026
Papyszoo added 15 commits June 4, 2026 01:45
Lets the Native Installers workflow run on the feature branch for testing
(workflow_dispatch needs the file on the default branch). Revert before merge.
…page)

The 'Export Node runtime path' step used a JS template literal that the
shell expanded away (PowerShell on Windows, bash on Linux/macOS), failing
every installer build. Rewrite it shell-safely via bash + node -p. Also
add homepage/author to both desktop manifests so electron-builder can
produce the Linux deb/AppImage.
node-canvas has no macOS-arm64 prebuilt and needs Cairo/Pango system
libraries at build and runtime, which broke the macOS native installer
and can't be bundled portably. @napi-rs/canvas ships self-contained
prebuilt binaries for every target (the lockfile now carries darwin,
linux gnu/musl, and win32 variants), so it builds and runs everywhere
with no system deps. createCanvas/getContext/toBuffer are API-compatible.
Add author email to both desktop manifests (electron-builder requires it
for the Linux .deb maintainer) and a concurrency group so a newer push
supersedes an in-flight installer build instead of running in parallel.
The previous package-lock-only edit left the lockfile out of sync
(npm ci reported missing transitive entries), which broke the macOS and
Linux installer builds at the asset-processor install step. Regenerated
from a clean install; npm ci now validates and all @napi-rs/canvas
platform binaries are recorded.
The Windows installer build failed because community.chocolatey.org
failed to download the postgresql package (transient CDN error). Retry
the install up to 3 times before giving up.
- PostgreSQL: put the Unix socket in a writable temp dir (-k). The default
  /var/run/postgresql isn't writable on Linux, which failed boot there.
- productName 'Modelibr' so app.getName()/userData is 'Modelibr' on every
  OS (was 'modelibr-desktop' from the package name; failed the Windows
  userData-preserved check).
- macOS install test: tolerate dangling symlinks in bundled Puppeteer
  Chrome when stripping the quarantine attribute.
EnterpriseDB (Chocolatey's source) returns 403 to GitHub datacenter IPs,
breaking the Windows build deterministically. Switch all three platforms
to Zonky's relocatable PostgreSQL 16 binaries from Maven Central, which
download reliably from CI and — unlike the Homebrew/apt builds — bundle
their own libraries, so they run on clean user machines too. Verified
locally that the extracted pg_ctl runs self-contained.
The bundled PostgreSQL binaries ship their own libs (ICU etc.), but
getPostgresEnvironment only set PATH — on Linux initdb failed with
'libicuuc.so.60: cannot open shared object file'. Add the lib dir to
LD_LIBRARY_PATH / DYLD_*_LIBRARY_PATH. Also launch the macOS app binary
directly in the e2e so its stdout/stderr is captured for diagnostics.
The bundled PostgreSQL libraries are symlink chains (e.g. libicuuc.so.60
-> .so.60.2). Those symlinks were lost when the runtime was packaged into
the app, so initdb couldn't load the exact referenced versioned names on
Linux and macOS. Copy the PostgreSQL runtime with dereference:true so
every referenced name is a real file. Verified locally.
The WebApi RestoreOnBootProcessor creates RESTORE_STORAGE_PATH (default
/var/lib/modelibr/restore) on boot, which isn't writable on Linux/macOS
and crashed startup. Set RESTORE_STORAGE_PATH and THUMBNAIL_STORAGE_PATH
(alongside the existing UPLOAD_STORAGE_PATH) to writable userData dirs.
- Drop rejectUnauthorized:false from the local health-probe request; the
  internal services are plain HTTP on loopback, so it was unnecessary.
- Rate-limit the edge server's static frontend file serving with
  express-rate-limit (generous cap; the /api and WebDAV proxies
  short-circuit before it). The server is loopback-only, so this is
  defense in depth.
…3.6.2)

Regenerating the lockfile from scratch bumped Prettier 3.6.2 -> 3.8.3
(within ^3.6.2), which reformats pre-existing files and failed
format:check. Re-derive the lockfile from main's, applying only the
node-canvas -> @napi-rs/canvas swap, so all unrelated devDep pins are
preserved and Prettier stays 3.6.2.
Two intermittent failures (they passed/failed on different event runs of
the same commit):
- Pack filter: the PrimeReact multiselect panel opens before its options
  (loaded from the packs API) render, so the option wasn't visible within
  a fixed wait. Retry with expect().toPass(): reopen if needed and wait
  until every requested option appears.
- Demo sound: soundExists() did an immediate count with no wait, racing
  the async sound-list load. Replace with an auto-waiting toBeVisible.
InitializeDatabaseAsync skipped MigrateAsync when CanConnectAsync was
false — but CanConnectAsync is false precisely when the database doesn't
exist yet. The native installer's embedded PostgreSQL only runs initdb
(no psql/createdb in the bundle, and nothing pre-creates the database the
way Docker's POSTGRES_DB does), so the schema was never created and every
data endpoint returned 500 (health checks pass because they don't touch
tables). Let MigrateAsync create + migrate the database; the existing
try/catch still degrades gracefully if the server is genuinely down.

Also add a /api/models smoke test to the installer e2e so a missing
database/migration is caught instead of slipping past health checks.
Papyszoo added 8 commits June 5, 2026 11:10
…led app

The installer e2e was only smoke checks (curl health), which let a real
bug (uncreated database) slip through. Run the actual Playwright suite
against the running native install instead:
- run-e2e-fast.js: make the WebApi/asset health URLs and Postgres
  connection env-overridable (defaults unchanged, so the Docker e2e is
  unaffected); allow skipping the asset health probe.
- test-installers: after install + smoke, check out the repo and run the
  full suite (setup/chromium/serial/slow/demo) against http://127.0.0.1:3010
  with Postgres on the launcher's loopback port. Validating on Linux first;
  fans out to Windows + macOS once green.
app.use(express.json()) consumed the body of every request, including the
ones http-proxy-middleware streams to the WebApi/WebDAV — so proxied
POST/PUT/PROPFIND (create model, change settings, WebDAV writes) hung and
returned 408. Only health/GET traffic worked, which the smoke test missed
but the real e2e caught immediately (globalSetup's settings PUT timed out).
Parse JSON only on the local /api/native/runtime PUT route instead.
enableHardwareAcceleration defaulted to true, which makes the asset
processor launch Chromium with real GPU flags (--ignore-gpu-blocklist,
--enable-gpu-rasterization, --use-angle=default). On any machine without a
usable GPU (GPU-less laptops, VMs, headless/CI) WebGL never initializes,
so the renderer times out and thumbnail generation fails. Default to
swiftshader (software) like the Docker e2e does — works everywhere — and
keep GPU acceleration as an opt-in via the Configuration panel.
The thumbnail renderer only surfaced a downstream 'window.THREE never
defined' timeout. Log pageerror/console-error/requestfailed during
template load so we can see the actual cause (e.g. a module the bundle
can't load) on the native install, where it fails but Docker/local don't.
… template

The <script type=importmap> was placed after a <script type=module>
(tiffDecode.js). Newer Chrome (bundled in the native installer) strictly
ignores an import map added after a module load has been triggered, so
'import ... from "three"' failed to resolve and thumbnail rendering hung
('window.THREE never defined'). Older Chrome (Docker/local) was lenient,
hiding it. Place the import map before any module script.
The WebApi runs as Production, where WorkerApiKeyFilter rejects an empty
WORKER_API_KEY as Unauthorized. The host spawned both the WebApi and the
asset-processor workers with WORKER_API_KEY='' , so every worker write
(technical metadata save, thumbnail/png/poster upload) got 401 and
thumbnails never became ready — the full e2e suite caught this where the
GET-only smoke test could not.

Mint a strong per-session key in ProcessManager and hand the same value
to both local processes (worker sends it as X-Api-Key, WebApi validates
it as WORKER_API_KEY). Both bind to 127.0.0.1 only, so an in-memory key
needs no persistence.
Software (swiftshader) thumbnail rendering is CPU-bound and a single
texture-set render can hold a worker for minutes. With one worker that
head-of-line-blocks every other asset. Default workerProcessCount to a
small CPU-aware pool (cores/2, capped at 2; 1 on low-core machines) so
capable machines render in parallel while memory stays bounded — each
worker runs its own headless Chromium. Still user-overridable in the
Configuration panel.
- PacksPage/ProjectsPage hardcoded the Docker e2e API port
  (http://localhost:8090) with no env fallback, unlike every step file.
  Running against the installed native build (its own ports) made all
  pack/project CRUD scenarios fail with ECONNREFUSED ::1:8090. Read
  API_BASE_URL with the same fallback the steps use.
- Raise the version-1 thumbnail snapshot wait from 120s to 300s to match
  the version-2 wait in the same scenario; under software rendering a
  queued thumbnail can take minutes on a non-GPU install.
@Papyszoo Papyszoo force-pushed the feat/native-installer branch from c548fbd to 2081f94 Compare June 5, 2026 14:10
Papyszoo added 13 commits June 5, 2026 17:03
The full Playwright suite is green against the installed native app on
Linux (253 passed across all phases). Remove the Linux-only gate so the
same suite runs against the Windows and macOS installs as well, and
capture the Windows app stdout/stderr (RedirectStandardOutput/Error to
RUNNER_TEMP) so a Windows-specific failure is debuggable via the
diagnostics artifact.
…ebGL)

On macOS the bundled Chromium cannot create a WebGL context through
SwiftShader (ANGLE→Vulkan→SwiftShader fails 'BindToCurrentSequence
failed'), so the Linux/Windows software path produced no thumbnails on
the Mac native install — the full e2e suite caught this where the
GET-only smoke test could not. Every Mac has a usable Metal GPU, so route
the non-hardware path through ANGLE's Metal backend on darwin; it renders
headlessly and faster than software. Verified locally: --use-angle=metal
yields a WebGL2 context (ANGLE Metal Renderer).
Modelibr is a GUI-subsystem app; Start-Process -RedirectStandardOutput/
-Error stopped it from initializing (the HTTP server never came up, so
'Wait for app ready' timed out) and produced no log anyway. Revert to a
plain Start-Process launch, which passed the Windows smoke checks in the
prior run.
SwiftShader-GL is correct but extremely slow on the GPU-less Windows
runners — the full thumbnail-heavy suite couldn't finish in 90 min.
ANGLE's default D3D11 backend falls back to WARP (a fast software
rasterizer) when no GPU is present, so it renders the same thumbnails far
faster while still working on machines without a GPU. Completes the
per-platform software-render path (macOS=Metal, Windows=D3D11,
Linux=SwiftShader).
The demo phase builds a standalone demo-mode frontend and exercises it in
Playwright's own browser — it never touches the installed native app, so
running it per-OS only added time (and surfaced demo-build flakiness on
the macOS runner unrelated to the install). Gate it behind
SKIP_DEMO_PHASE and set it for the native run; demo mode stays covered by
the Docker e2e CI. Also raise the job ceiling to 120 min as a buffer.
…ows runner

D3D11 rendering cut the Windows serial phase from ~1.4h to ~11m, but the
parallel Chromium UI phase is still ~1.6h on the 4-core Windows runner
(vs ~7m on Linux) due to CPU contention with the native stack. The suite
passes there — it just needs wall-clock — so raise the ceiling. macOS and
Linux finish in well under an hour. This heavy job only runs on releases
once the temporary push trigger is removed.
…ed runners

The installed-app run shares a 4-core hosted runner between the native
stack (Postgres, WebApi, asset workers) and the parallel Playwright
browsers. On the much slower Windows runner this contention made UI
actions miss the 90s test timeout and 15s waits (14 chromium + 2 slow
flaky failures), while the same tests pass on Linux/macOS. Run the
chromium phase with 2 workers instead of 3 and make the per-test timeout
overridable (PW_TEST_TIMEOUT=180s for the native run). Linux/macOS still
finish with room to spare.
- Expose backend (internal API) and database ports in the Configuration
  panel alongside the app port; all are restart-applied.
- Add a configurable data folder (Browse / Use default) so all state —
  uploads, thumbnails, embedded Postgres — can live on another drive.
  ProcessManager resolves config.dataDirectory, falling back to the
  default under userData.
- Make Restart idempotent: a single guard prevents the double-click
  relaunch that spawned duplicate instances and crashed the app, and the
  button now shows 'Restarting…' and disables itself.
Replace the single host-URL field with separate Host and Port inputs (with
a live URL preview and validation) so a client can point at a Modelibr
host on another machine on the network. The page doubles as proactive
'Connection Settings' (from the menu) and the post-failure reconnect
screen, adapting its copy via a 'failed' flag.
'Install' now downloads the matching desktop-client installer for the
current OS/arch from the latest GitHub release and launches it (NSIS
wizard on Windows, mounts the dmg on macOS, runs the AppImage on Linux)
with live progress in the status window — instead of sending the user to
the releases web page. Falls back to opening the releases page if no
matching asset is found or the download fails.
Both apps now check GitHub Releases on launch, download a newer build in
the background, and install it (host: a 'Restart & Install' action in the
tray + status window with live download progress; client: installs on
quit, with a 'Check for Updates' menu item). Adds electron-updater, a
github publish provider, and a macOS zip update payload to both builds,
and publishes the update feed (latest*.yml + blockmaps) to releases.

macOS auto-install requires a signed build (Squirrel.Mac verifies the
signature); on unsigned macOS the step is caught and falls back to
opening the releases page. Windows (NSIS) and Linux (AppImage) update
without signing.
…thrash

Two parallelism problems surfaced running the full suite against the
installs:

- Multiple asset-processor *processes* double-claim a job (the server-side
  dequeue isn't atomic across processes): two workers render the same job,
  one wins and the other's upload 400s and marks the job failed — which
  failed setup on Linux. Revert the default worker count to 1; per-worker
  concurrency still parallelises safely within one process.
- On the 4-core Windows runner, parallel Playwright browsers + the native
  stack thrashed: the Chromium phase took ~1.6h with flaky timeouts even
  at 2 workers. Run the installed-app suite single-worker (PW_WORKERS=1) —
  no thrashing, reliable, and still well within the 180m ceiling (the
  workers=1 setup/serial/slow phases already finish quickly there).
Properly support multiple worker processes instead of forcing a single
one. The dequeue was read-then-write: GetNextPendingJob (SELECT) →
TryClaim (in-memory) → UpdateAsync (SaveChanges). Two worker processes
both read the same pending job and both 'claimed' it, so one finished it
and the other's upload 400'd and marked the job failed.

Claim atomically instead: a single conditional UPDATE … WHERE Id = @id
AND Status = Pending (ThumbnailJobRepository.TryClaimPendingJobAsync via
ExecuteUpdateAsync). Under PostgreSQL row locking exactly one racing
worker's update changes the row; the losers match zero rows and poll
again. No schema change or concurrency token needed.

Restores the CPU-aware default worker pool (reverting the stop-gap forced
single worker) now that multi-worker claiming is race-free.
if (!claimed)
{
_logger.LogWarning("Failed to claim job {JobId} for worker {WorkerId}", job.Id, workerId);
_logger.LogDebug("Worker {WorkerId} lost the claim race for job {JobId}; will poll again", workerId, job.Id);
Papyszoo added 7 commits June 6, 2026 17:24
The hosted Windows runner is far slower for the 3D-heavy UI, so the
highly-parallel Chromium phase flakes on timeouts (~4/107) even
single-worker, while the install-critical phases pass. Keep the Chromium
phase running and reported on Windows but non-blocking
(CHROMIUM_PHASE_NONBLOCKING), so it no longer fails the Windows job;
setup/serial/slow + smoke stay strict everywhere and Linux/macOS still
gate the full UI sweep. Also raise Playwright retries to 2 for the native
run (PW_RETRIES) to absorb the flaky-timeout tail — retries only re-run
failures, so coverage isn't weakened.
Validation is complete (full E2E suite green against the installed app on
Windows, macOS, and Linux). Remove the temporary per-push trigger; the
workflow now runs on published releases and manual workflow_dispatch.
…ped assets)

GitHub always serves workflow-run artifacts as a single .zip, so a
workflow_dispatch build left every installer bundled together. Add a
publish-draft-release job that, on manual runs only, collects all built
installers into one draft GitHub Release where each file is a separate,
directly-downloadable asset (bare .exe / .dmg / .AppImage / .deb) with no
zip wrapper — matching how a published release already serves them.

- Draft, so it stays private until explicitly published.
- Gated on the build jobs only (not the long test-installers suite) so a
  try-it build is downloadable as soon as it finishes building.
- Uploads only user-facing installers, not the electron-updater feed files
  (latest*.yml / *.blockmap), which are machine-only and whose fixed names
  would collide between the host and client apps.
- Production releases (release: published) are unchanged.
The runtime config was overloaded to mean both "what's saved" and "what's
actually running": saving a port change called updateConfig() immediately, so
the snapshot/Frontend URL/Open button jumped to the new port while the edge
server was still bound to the old one. After a second change the UI showed a
port nothing was serving yet ("new URL doesn't work, old one does"), with no
clear signal that a restart was still pending.

Split the two:
- ProcessManager tracks activeConfig (captured by markRunning() once boot
  succeeds) separately from config (latest saved). Snapshot URLs, health
  probes, and worker health ports all report the ACTIVE values, so the shown
  URL always works. hasPendingRestart() exposes the desired-vs-active gap.
- Unify restart-required detection in a shared requiresRestart() helper so the
  tray IPC path and the in-browser PUT /api/native/runtime path agree. The web
  path previously only checked appPort, so changing the backend/database port
  there silently did nothing.
- status.html: surface the pending state on the Restart button ("Restart to
  apply") and fix the success toast, which rendered transparent text on top of
  the footer Restart button — now an opaque centered pill.

Add a Node built-in test suite for the desktop app (no new deps) covering
multiple sequential config changes, restart-required detection, and the
save → persist → relaunch flow, and run it on PRs via the Code Quality workflow.
…ecycle, robust relaunch

Follow-ups from the self-review of the config-honesty change:

- Add a REAL edge-server integration test that binds an actual port and drives
  it over HTTP, proving a saved port change does not move the live server and
  keeps serving the bound port (the previous tests only checked the bookkeeping).
- Resolve the data folder when detecting a pending restart, so picking the path
  the default already resolves to is no longer a false "restart required".
- Workers now target the ACTIVE API port, so the pool can be recycled safely
  even while a port change is pending — both save paths apply worker settings
  live unconditionally, removing the deferred/stranded-worker-config corner.
- Harden the relaunch: release the single-instance lock before app.relaunch(),
  so the replacement instance can always acquire it (fixes the "pressed Restart
  and nothing happened" race where the new instance quit on a held lock).
- edgeServer no longer duplicates the restart-required check — hasPendingRestart
  is the single source of truth, identical to the tray path.
- status.html: hoist the `restarting` flag out of the TDZ render() reads it in;
  let the toast wrap instead of clipping.
- CI: desktop-tests installs runtime deps (npm ci --omit=dev) for the edge test,
  still skipping Electron; manual draft release uses one stable, reused draft
  (no per-run pile-up) and is clearly marked "not verified — built, not tested".
…restarting

Three problems seen while restarting the installed host app:

- The ~2-minute "Restarting…" wasn't a double-restart (that's guarded): the
  graceful shutdown stops services sequentially and `pg_ctl stop` waited up to
  its 60s default for Postgres, so a restart could take ~90s. Cap it with
  `-t 20`, and add an absolute 30s before-quit deadline that forces exit (so a
  relaunch always proceeds) if any stop step ever wedges.
- The status window kept painting green Backend/DB/Asset dots during the
  relaunch because the still-draining old instance reports "up". render() now
  freezes to a pending "restarting…" state while a restart is in flight, and
  the click handler sets that state immediately.
- Reaffirm the re-entry guard so a second Restart click (or Enter) can't fire a
  second relaunch.
…sh reconnect

Addresses three gaps found while testing:

- The host now detects an already-installed desktop client at its default
  install location (Windows %LOCALAPPDATA%\Programs / Program Files, macOS
  /Applications, Linux /opt). When present, the button reads "Open" and
  launches it instead of re-running the installer; a wrong guess (custom dir)
  falls back to the install flow. Detection logic is split into pure,
  dependency-injected helpers with unit tests.
- The host Settings panel notes that the app port is what desktop clients
  connect to, so changing it requires reconnecting them.
- The client's connection page, after a failed load, now explicitly calls out
  that the host's app port may have changed, points at the host's Frontend
  address, and focuses the port field ready to edit. (Prefill/retry/validation
  were already there.)

Note: the host cannot push a port change to a client (it may be on another LAN
machine), so a manual reconnect is inherent — the client already falls back to
its connect page when the host becomes unreachable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants